iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 22

Day 22:XML、JSON 與 MessagePack 的分工

  • 分享至 

  • xImage
  •  

Day 22:XML、JSON 與 MessagePack 的分工

昨天結尾說換一個層面。從這裡開始,題目不再是定義裡宣告了什麼,而是這些定義與資料離開記憶體之後怎麼走。

序列化是兩件事。一件是持久化:物件要存下來,下次還得原樣讀得回來,定義是這樣存的,SessionUser 這類系統資料也是。另一件是傳輸:物件要送到另一端,而那一端不只一種:桌面、瀏覽器、iOS、Android,執行環境各不相同。

本篇說明:

  1. 持久化走哪一條、傳輸為什麼分兩條,分界線照什麼判準畫
  2. PayloadFormat 怎麼決定一次呼叫的 value 送出去長什麼樣
  3. 定義類的東西為什麼傳出去是一段 XML 字串,不是一棵 JSON 樹
  4. 傳輸型別為什麼一個都不能漏註冊
  5. 反序列化那一端,型別由誰決定,以及為什麼只有一條路需要白名單

一、持久化一條路,傳輸兩條

持久化要的是人看得懂、能進版控、能手動改,這一件從頭到尾走 XML。

傳輸分兩條,分界在呼叫端是不是 .NET:

  • 前後端都是 .NET:value 走 MessagePack
  • 前端不是 .NET:純 JavaScript 前端或第三方整合,value 走 JSON

外面那一層兩條路都一樣:一次呼叫送出去的是一份 JSON-RPC payload,協定規定它是 JSON,換不掉。有選擇的是裡面那一格 value。不另外編碼的時候,它就是那份 JSON 的一部分;編碼過才輪到上面那兩條路。

持久化一條加傳輸兩條,三個格式各自用擅長的那一個:

格式 在框架裡負責什麼 需要外部套件 誰在用
XML 定義與系統資料的持久化,以及定義類 API 的回傳內容 否,BCL 內建 定義層
JSON 外面那份 JSON-RPC payload,以及編碼過的 value 的其中一種寫法 否,BCL 內建 API 層
MessagePack 編碼過的 value 的另一種寫法,也是沒有另外宣告時的那一個 API 層

第三欄不是附註,它是第四欄的理由:一個格式需要外部套件,它就不能放在定義層。定義層在相依圖的底部,資料庫、Repository、業務邏輯、每一個前端與定義檔工具都在它下游,加在這裡的任何套件會沿著相依鏈傳染給所有消費者,而其中沒有一個用得到它。

換到的東西是:日後要改傳輸格式,定義層零改動。代價是傳輸綁定必須在 API 層另外寫一份,而那一份跟定義型別之間沒有編譯器綁著。


二、PayloadFormat 決定 value 送出去長什麼樣

Day 16 談 params 那一格時提過 formattype,當時只寫了它們在 API payload 上而不在 value 裡面。這兩格的內容就是這一節。

format 有三個值,決定的是同一件事:這次呼叫的 value 送出去要以什麼形式出現。

value 送出去長什麼樣 經過哪一個 serializer 誰在用
Plain 一棵 JSON 物件樹,人看得懂 就是 JSON-RPC payload 那一份 System.Text.Json 登入之前的幾支呼叫
Encoded 一段 Base64 呼叫端宣告的那一個,序列化後壓縮 登入本身,以及降級的落點
Encrypted 一段 Base64 呼叫端宣告的那一個,壓縮後再加密 其餘全部,呼叫端的預設值

壓縮與加密那一段管線本身是明天的題目。

同一支方法在三個值底下都走得通,這正是把格式放在 API payload 上、而不是放在方法名或路徑上的用意。對案例的登入送一個 Plain 的 API payload,value 就是一份可讀的 JSON:

{"jsonrpc":"2.0","method":"System.Login",
 "params":{"format":0,"value":{"userId":"demo","password":"demo"},"type":""},"id":"1"}

回來的也是可讀的:

{"jsonrpc":"2.0","method":"System.Login",
 "result":{"format":0,"value":{"accessToken":"033bedcf-...","userName":"Demo User",
 "timeZone":"Asia/Taipei"},"type":""},"id":"1"}

同一支方法把 format 改成 1,其餘一字不動,換來的是一則錯誤:

{"jsonrpc":"2.0","method":"System.Login",
 "error":{"code":-32099,"message":"TypeName is missing for deserialization."},"id":"1"}

type 這一格只有後兩個值用得到,而原因正好說明三個值的差別在哪裡。Plainvalue 從頭到尾沒有離開 JSON,伺服端拿到的是一段還沒有型別的 JsonElement,等到反射查出那支方法宣告的參數型別之後,才把它反序列化成那個型別。型別是從方法簽章來的,不是從 API payload 來的。後兩個值不一樣:它們在呼叫端就把物件打成一串 bytes,而 bytes 自己不帶型別資訊,於是型別名得寫在 API payload 上跟著送。這一格因此是呼叫端說了算的欄位,伺服端在 Type.GetType 之前必須先拿它比對一份型別白名單。

Plain 不是偵錯用的旁路,它是啟動路徑。連通性探測、取得共用設定、建立連線這幾支,都發生在傳輸金鑰存在之前,那時候沒有東西可以拿來加密。

三個組件,兩種決定方式

value 上那三個組件各自是一個介面加一個以名字取實作的工廠,差別在名字從哪裡來:

組件 名字寫在哪裡 目前有的實作
serializer 每一次呼叫的 API payload 上 MessagePack、JSON
compressor 部署的那份 SystemSettings Gzip、不壓縮
encryptor 部署的那份 SystemSettings AES-CBC-HMAC、不加密

取不到對應的實作就擲例外,不會安靜退回預設。

分成兩種,是因為它們回答的不是同一種問題。壓縮與加密回答的是這個部署要不要保護 value、用什麼保護,那是部署方的決定;serializer 回答的是這個呼叫端寫得出哪一種 value,那是呼叫端的能力。把後者也收進部署設定,等於要求同一台伺服器上的每一個呼叫端都說同一種話,而第一節那兩條路本來就不說同一種。

沒有宣告的呼叫一律當成 MessagePack,因為還沒認識這一格的呼叫端送的就是它。

這三個組件整個關在兩個地方:呼叫端在 Connector 裡,伺服端在 API 層裡。取一頁清單的方法,簽章上只有查詢欄位、條件、排序與分頁,沒有一個參數跟格式有關;BO 那一端拿到的也是還原好的物件。所以日後換一個更適合的傳輸格式,動的是 serializer 那一格,前後端的應用程式碼都不必改。


三、定義類的東西為什麼是一段 XML 字串

前一節說 value 可以是 JSON 樹。那麼取一份 FormSchema 回來,收到的應該也是一棵。實際上不是。同一個 Plain 的 API payload,向案例要一份訂單的 FormSchemavalue 裡只有一個欄位:

{"result":{"format":0,"value":{"xml":"<?xml version=\"1.0\" encoding=\"utf-8\"?>
<FormSchema ProgId=\"Order\" CategoryId=\"company\" ...>
  <Tables><FormTable TableName=\"Order\" DbTableName=\"ft_order\" ..."},"type":""}}

一段 XML 字串,裝在 API payload 那棵 JSON 樹裡。定義類的東西全部走這條路。API 層只負責把字串送到,還原成物件是呼叫端的 Connector 在做,所以 XmlSerializer 在每一個前端上也要跑一次。

理由是持久化那一份現成可用:定義本來就以 XML 落檔,傳出去沿用同一套序列化,零額外成本。

要讓定義型別也走 JSON 或 MessagePack,代價是持續的。每多一種序列化就多一組要顧的事:來回一趟資料會不會丟、反序列化時擋不擋得住不該被建出來的型別。而定義是巢狀結構、又會隨版本演進,每加一個屬性都要在每一種格式上再確認一次;日後換一個更適合的傳輸格式,整組定義型別還要再驗一輪。

而它們現在的形狀也確實不適合:集合屬性是唯讀的,各自握著自己的擁有者參考。三個 serializer 對這種屬性的處理方式剛好分成兩邊:

讀回來的時候怎麼做 遇到唯讀的集合屬性
XmlSerializer 取出既有的實例,把值一項一項填進去 正常還原
System.Text.Json 建一個新集合,指派回去 指派不了,整段跳過
MessagePack 建一個新集合,指派回去 指派不了,整段跳過

兩邊的差別不在能力,在方向。而在建新集合那一邊,「能不能指派」就是「這個成員存不存在」。


四、傳輸型別一個都不能漏註冊

MessagePack 那一側的規則是另一套。既然它連唯讀屬性都不看,最省事的作法就是讓它自己用反射把成員找出來,什麼都不必宣告。桌面上這樣確實跑得動。

問題是這條路靠的是執行期現場產生程式碼,而這個能力不是每一種執行環境都給得起。給不起的地方,一個沒有註冊的型別不是變慢,是直接擲例外。所以每一個傳輸型別都得顯式註冊一份 formatter,把成員逐一列出來,下面這段出自表單那一族的 WireContracts.Form.cs

WireContract.For<GetListRequest>()
    .Member(nameof(GetListRequest.SelectFields), static x => x.SelectFields, static (x, v) => x.SelectFields = v)
    .Member(nameof(GetListRequest.Filter), static x => x.Filter, static (x, v) => x.Filter = v)
    // 其餘成員逐一列出
    .Build();

nameof 那一格不只是可讀性。formatter 寫出去的是以屬性名為鍵的 map,讀回來不認得的鍵就跳過,沒收到的鍵讓屬性留在宣告上的預設值。Day 16 那條「一個方法一個參數物件」因此多換到一件事:替 Args 加一個屬性,不會讓還沒升級的呼叫端打不通。這是現在這個形狀給得出的性質,不是一條被守著的承諾,沒有任何一個測試在驗它。

冗長是必然的:改用一支通用的 formatter 去讀屬性型別,就又回到現場產生程式碼那條路。所以這份清單只能手寫,也沒有產生器可以重跑。

集合在這條路上還有幾個各自不同的雷,其中有些在開發機上完全正常,所以「桌面測起來沒事」不能當成證據。

這份清單跟型別之間沒有編譯器綁著,不漂掉靠的是一組測試。它從同一組訊息型別出發,把所有到得了的走一遍,兩頭都比對:走得到的都要有註冊,註冊了卻沒有人到得了的也要被指出來。而且這組測試可以在開發機上模擬那種環境跑,不必等到部署出去才發現。

這一整包收在同一處:套件的參考、所有 formatter、這份清單,全都在 Bee.Api.Core 底下的一個資料夾裡,其餘組件都不必認識 MessagePack。第一節那張表的第三欄問的就是這件事。


五、反序列化這一端,型別由誰決定

反序列化是把外面送來的位元組變成行程裡的物件。而「變成哪一個型別」握在誰手上,決定了這件事有多危險,這裡有三個不同的答案。

XML 那一條最封閉。伺服端要把一份定義存回去時,型別不是從 XML 內容讀出來的,是拿請求上那個 DefineType 去查一張固定的對照表;表上沒有的值直接擲例外。

Plain 那一條由方法簽章決定,也就是第二節那段 JsonElement:呼叫端連一個可以填型別的欄位都沒有。

編碼過的那一條是唯一由呼叫端說了算的,第二節那個 type 欄位就是它,走哪一個 serializer 都一樣。bytes 不帶型別,型別名只能寫在 API payload 上跟著送,所以 Type.GetType 之前一定要有一道白名單。它篩的是整串名字,泛型引數與陣列元素一個都不放過;剖不動就拒絕,而且整道檢查發生在物件被建出來之前,不是建好之後才看。

不擋的話,呼叫端就等於指定了伺服端要建出哪一個型別。危險不在送來的資料,在於有些型別一被建出來就會做事,而反序列化做的正是建物件。

白名單本身分兩層:一組固定的基本型別,加上應用啟動時宣告的命名空間,框架自己那幾個是預設值。第二層放得越寬,這道檢查擋得越少,那是應用自己的選擇。

型別對了,不代表內容不能作怪。XML 這一側另外禁掉了 DTD:XmlSerializer 直接吃一段字串時已經擋掉「去外面載一份資源進來」那一種,但文件自己宣告的縮寫還是會被展開,而那足以讓一份幾 KB 的檔案在展開之後吃光記憶體。框架讓所有 XML 都走同一個收緊過的 reader 進來,於是每個呼叫端不必各自記得這件事。


回到 Northwind

案例的伺服端,跟序列化有關的程式碼是零行。唯一一處 JsonSerializer 在建表種子那支程式裡,讀的是自己的種子資料,跟傳輸無關。

前端也一樣。四個前端共用同一個 UI 專案,那個專案裡沒有一行在處理格式,連定義那條 XML 的還原都是 Connector 做掉的。三種序列化全部關在那一層裡,走哪一個、怎麼壓、怎麼加密,上面的程式碼都不必知道。

零程式碼不等於零成本。這四個裡,除了桌面那一個,其餘都在建置檔上留了一筆放行:瀏覽器那一個跑在 wasm 上,要明確打開反射序列化,否則第一支呼叫就送不出去,因為框架的訊息型別沒有走原始碼產生,而每一次呼叫都要經過那層 JSON-RPC payload。掛的不是 value,是外面那一層。應用要付的那一份落在建置設定上,不在原始碼裡。


小結

持久化那一件從頭到尾走 XML:定義本來就要落檔,傳出去沿用同一份,零額外成本。

傳輸那一件分兩層。外面那份 JSON-RPC payload 是協定的固定格式,有選擇的是裡面那一格 value:前後端都是 .NET 走 MessagePack,沒有宣告時也是它,純 JavaScript 前端與第三方整合走 JSON。

value 上那三個組件全關在兩個地方:呼叫端的 Connector 與伺服端的 API 層。日後要換掉其中任何一個,應用程式碼一行都不必改。

明天接著看 value 上的另一半:序列化之後的壓縮與加密,以及那個順序為什麼不能調換。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 21:業務邏輯客製與 Plugin 的四個時點
下一篇
Day 23:API Payload 安全管線:順序、保護等級與金鑰
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言